离线预渲染OPR:0成本接入 媲美SSR效果
关注我们 文末有福利
作者简介
张所勇
转转平台运营中心前端负责人,在前端领域有深入研究,包括:sketch一键切图、前端数据模型化,小程序基础能力建设等多个方面,10年工作经验中,做了2年工程师,5年CEO,3年技术管理,能写点文章,也是2018年度掘金优秀作者。
细数现阶段业内首屏优化方案,主要有:SSR、Prerender、CSR等方案,这些方案的思路几乎都在于将渲染过程放到传统SPA用户端渲染之前,而传统性能优化手段在SPA项目上面收获甚微,原因在于SPA本身的致命缺陷:
SPA方案
在SPA项目的首屏性能上,我们在长期关注和不断探索,期间我们尝试过很多方案,包括:
从减少代码体积角度的:webpack优化、打包优化、tree-shaking等
从减少HTTP请求角度的:接口合并、按需加载、延时加载等各种方法减少请求
从缓存角度出发:离线包、http&浏览器各种缓存使用、dns预解析、dll方案、接口缓存方案等
从数据获取时机角度出发:webWorker预取数据、路由进入过程读取数据等
从减少图片体积和数量出发:使用webp图片、请求域名并行优化、CSS Sprite等
这些方案都能一定程度上降低白屏时间和首屏时间,但收效有限,很难像SSR方案一样大幅降低数据,究其原因,SPA页面渲染过程如下:
滑动查看图片
从上图可以看到,白屏过程几乎是不可避免的,因为无论如何你去优化代码体积,Vue系列类库和你需要的其他核心类库文件加起来至少有几百K,在加上这些文件执行的时间(实测至少500ms),可能大多数情况,我们白屏时间至少1200ms-1500ms了。
当然,我们可以把骨架屏所需的css放到HTML里面,能尽早的显示出骨架屏(但很多低版本内核需下载&执行完全部script后才会渲染页面),但这并非真正的首屏,即使在性能统计上,也无法直观反馈出首屏的提升。
于是SSR方案成为我们的救命稻草:
SSR方案
我们再看下SSR如何解决这个问题:
滑动查看图片
SSR方案的优势在于,浏览器下载的HTML当中已经具备了首屏渲染所需的DOM结构和样式,白屏时间几乎等于HTML文件下载时间,而这个时间相比SPA已经很少了,性能数据有显著提升。
那为什么我们不直接用SSR方案呢?
主要原因有四点:
1、SSR项目改造成本高
Vue技术栈的SSR方案主流有两种:官方方案和Nuxt.js,这两种方案相同点都是:
必须把现有webpack各项配置替换成上述两种方案工程
工程所有页面都必须SSR方式的要求实现
必须在自定义的asyncData/preFetch生命周期内获取数据
必须将接口数据使用Vuex管理
或许你认为这个也不难啊,对于一个新项目,确实不难,但对一个老项目来讲,上述的改造成本和测试成本就无比高了,这也是少有老项目改造SSR的原因。
2、SSR性能依赖接口性能
从SSR原理上你可以知道,SSR服务端渲染过程依赖于获取到全部数据才能开始渲染,一旦接口出现延时或超时,那首屏性能也会受到影响。
3、SSR负载能力和扩容能力可能成为瓶颈
几乎是业界公认,node的负载能力相比java等要差一些,相比nginx静态资源服务更差,并且很多公司在node服务器快速扩容上面,目前还没有太多实践和机制保障,虽然可以通过备足服务器来抵抗流量高峰,但毕竟这对应的是成本。
4、SSR无降级方案
一旦node服务故障,页面可能直接就会白屏,很多时候不是重启服务能够解决的,毕竟SSR不是像SPA一样在浏览器看见什么错误去解决或者回滚就可以的,你必须真正解决了故障才能恢复服务,这期间不能很容易的降级为SPA方案。
上述原因当中,最主要阻碍我们用SSR的原因是改造成本。
Prerender方案
Prerender是基于prerender-spa-plugin这个webpack插件实现的,原理如下:
滑动查看图片
核心原理就是在webpack打包过程中,通过Puppeteer访问对应路由,抓取html并静态化,再部署cdn。
但业内这种方案使用的比较少,主要原因有:
静态化过程发生在构建环节,用户访问时看到的数据注定是过时的。
这种方案依赖于使用history方式的路由,这对老项目的改造测试成本也不低。
编译时间大幅增加,想想就知道啦。
通过上述分析,我们能看出“最优方案”应该是SSR,不考虑负载能力的话,阻碍我们的只有改造成本了,能否用较低的成本实现跟SSR一样的效果呢?
离线预渲染OPR
晴空一声惊雷,OPR产生了,我们把他命名为离线预渲染OPR(Offline Prerender)。
OPR的渲染过程:
滑动查看图片
不同于SSR在用户访问阶段的渲染,OPR是一个独立于用户访问流程的渲染服务,它通过Puppeteer定期渲染页面并上传cdn,用户访问到的页面将会是纯静态页面,可以说是结合了SSR和Prerender两种方案。
与SSR方案的区别:
渲染过程独立于用户访问,没有服务器压力,占用资源极小,一台服务器即可完成
页面几乎不需要任何改动
渲染出来的页面效果几乎和SSR一致
可降级为SPA方案
与Prerender方案的区别:
通过定时渲染,解决Prerender方案数据无法及时更新的问题
页面几乎不需要任何改动
对原本项目构架过程无任何影响
OPR方案实现过程
我们简单拆解来看:
01
定时访问页面
我们首先搭建一个node服务,通过schedule机制定期通过Puppeteer访问需要渲染的页面。
02
等待页面渲染
页面渲染是一个动态的过程,我们如何知道页面已经渲染完了呢,Puppeteer其实提供多种方案,但我们最终选用的方案是通过监听公司性能统计埋点发出时机,通过Puppeteer的page.waitForRequest方法可以很容易实现。
03
抓取HTML
你必须清楚一点:我们抓取的是浏览器渲染的HTML,并非你请求到index.html文件内容。
前者你可以理解为,通过浏览器开发者工具,选中html标签,右键拷贝outerHTML。
后者你可以通过浏览器查看下html源码,里面应该只有空白的dom和一些<script>标签。
前者内容可能是这样的:
后者内容是这样的
其实抓取HTML这个动作通过Puppeteer一句代码就可以实现:page.$eval('html', e => e.outerHTML),但抓取到的HTML我们做了很多处理:
OPR渲染标识
为了让页面知道是被OPR渲染出来的,我们将会在HTML里面注入一个变量:__offline_prerender_data__,这个变量既起到标识作用,也可以用来存放一些特殊数据
抓取接口数据
熟悉SSR过程的同学可能知道,SSR会把服务端渲染阶段所需的数据写入到HTML中,用户端渲染时会进行一次数据校验,有了这些数据,用户端也可以尽快的完成二次渲染(下文会讲到)。
在OPR当中也可以提供这样的能力:
如果开发者在配置文件当中设置了useDataCache : true,则我们会监听页面渲染完成之前所有的接口请求,并将数据打入到HTML当中,同时帮你注入一段代码,能让HTML执行时把数据存入localStorage中,供一些接口缓存库来使用
大体代码如下:
04
解决适配问题
我们在服务端puppeteer里面模拟的设备环境跟用户实际设备肯定不一样,那我们就要解决样式适配问题:
a、在head里面注入rem刷新代码
b、将页面当中的px转换为rem
方案比较简单,这里就不贴代码了。
05
去除无用内容
我们将HTML当中无用的内容去掉了,包括:
06
对比
我们期望降低一些更新cdn的频率,因此我们把渲染好的HTML会和上次渲染的HTML进行对比,如果内容一致就不会重复渲染,其中主要有两种情况导致渲染不一致:
a:过程中页面有新上线
这时两次的HTML里<script>地址不一样了,必须会再次上线
b:页面当中数据发生了变化
需要上线情况:接口数据变化了
不需要上线情况:页面当中重要程度不高的数据变化,比如倒计时、购买人数等
为了减少上述不需要上线情况导致的上线,开发者可以在对应DOM上加上offline-prerender-tag-nodiff样式名,OPR在diff的过程就不会比对这块的DOM。
07
上传cdn
OPR会把渲染好的页面根据url地址上传到cdn,例如:
转转图书首页:https://m.zhuanzhuan.com/open/ZZBook/index.html#/Book/Home
会被上传到cdn:https://m.zhuanzhuan.com/open/ZZBook/index-Book-Home.html#/Book/Home
为什么不是index.html,这样有几个好处:
a:将OPR地址和SPA地址区分开
我们只需要将入口地址替换为
https://m.zhuanzhuan.com/open/ZZBook/index-Book-Home.html#/Book/Home
用户就可以访问到OPR渲染的页面了,如果用户访问到原本的index.html,那只是访问到原本的SPA页面,只不过渲染速度会慢一点而已。
b:降低风险
试想下,如果我们把文件写入成index.html,那如果用户首次访问的是/Book/Mine的路由,用户会很奇怪,我访问的是个人中心页面,为什么你要先给我展示首页呢。
同时,也防止了OPR服务发生了意外而导致所有页面都无法访问的风险。
二次渲染
上文讲过,用户访问SSR渲染的页面和OPR渲染的页面,都会发生两次渲染:
浏览器渲染HTML当中的DOM(我们叫首次渲染,注意此时页面是静态的,无法交互)
Vue还会重新渲染并维护一份虚拟DOM,并把虚拟DOM和HTML当中的DOM进行一次混合(因为两个DOM完全一致,你完全看不到发生变化,但是可以进行交互了,我们简称二次渲染)
必须要有二次渲染,为什么?
因为首次渲染仅仅是DOM层面的展示,我们必须把Vue整体逻辑赋予DOM里,否则就没有各种事件,用户将无法交互。
SSR和OPR两种方式首次渲染过程都是一样的,但二次渲染有很大的区别:
SSR:
会先从html当中拿到数据,然后进行$mount渲染挂载,这里面Vue代码给SSR定制了一个特殊流程:会尝试跟页面当中DOM进行混合,如果完全一致就直接复用DOM,把虚拟DOM混合到页面中:
OPR:
OPR项目的二次渲染没有想象的那么简单,他很难做到复用DOM,主要因为:
OPR页面很难做到DOM完全一致
因为SSR项目是拿到所有数据进行一次性渲染,无论是服务端生成HTML的时机还是用户端二次渲染的时机都是完全一样的,数据也是完全一样的,因此能渲染出完全一样的DOM,如果虚拟DOM和页面中DOM有一丝毫差异,Vue都会删除掉页面已有DOM再使用新的DOM。
SPA页面会有个逐渐使用和渲染的过程,例如:
在OPR或者SPA页面中渲染是多次完成的:
1、当ready=true时先完成第一次渲染,同时开始渲染goodsList组件
2、goodsList会触发第二次渲染
而第一次渲染时DOM和页面当中并不一致,那么旧的DOM就直接被抛弃掉了。
那如果上述页面我们把结构写的更简单一点可以吗,比如所有组件都平铺开,不用v-if控制,所有数据都使用缓存数据,并且同步读取数据不能包含异步流程,这样理论上能完全复用DOM,但现实中是不太可能的,业务当中逻辑远比想象的负责,我们当然也不希望业务进行大量改造,这就违背OPR的初衷了。
因此OPR方案选择的二次渲染解决方案是:延时挂载DOM
这个方案的核心在于,只要你在new Vue的过程中,不传el参数,那么Vue就会在没有挂载到页面的DOM当中完成渲染和虚拟DOM的构建工作,那我们只要在一个合适的时机把DOM挂到页面上就行了,这个合适的时机是什么呢?
页面DOM和虚拟DOM接近一致
我们先说下“挂载”是怎样的过程:简而言之就是把HTML当中DOM删掉,再把虚拟DOM的句柄window.vm.$el插入body中。
大家可能会担心有性能损耗吧,实际上这个DOM删除和插入的过程非常快,对于浏览器进程资源消耗非常少。
再回到挂载时机,如果页面差异过大的时候挂载会怎么样?那用户会感到页面闪动,因为有局部样子不一致,这个应该比较好理解,所以我们要在两个DOM接近一致的时候做挂载操作
延迟挂载时机
为了能知道何时两个DOM接近一致,我们借鉴了淘宝的性能统计方案中的首屏结束时机的计算方案:
我们通过MutationObserver来监听虚拟DOM的变化,每次变化时计算一次DOM分数,当DOM分数和页面已有DOM分数接近的时候,我们去挂载DOM
具体逻辑更复杂一点:
滑动查看图片
当两个DOM几乎一致的时候,再去挂载DOM,用户就几乎感觉不到页面闪动了。
同时参考上文,如果使用了接口缓存机制,就减少DOM趋近一致的时间了。
其他优化
为了更进一步提升首屏速度,我们在OPR方案上还做了一些优化:
01
CSS tree-shaking
我们抓取的HTML里面会有很多style标签,几乎可以认为,你曾在vue里面写了多少个style标签,head里面就至少有这么多标签,例如:
这些标签里面有大量重复的样式,如果打入HTML将增大文件体积,我们对此进行了tree-shaking,可以讲原本5000行+的css代码精简到1000+,具体如何实现,我们将会在以后的文章中讲解。
02
将script改为async加载模式
在此之前,你可能需要了解下普通script、defer、async有什么区别。
简而言之,
普通script: 按书写顺序下载、执行,阻碍DOM渲染,阻碍DOMContentLoaded时间
defer script:并行下载,按照书写顺序执行,不阻碍DOM渲染,阻碍DOMContentLoaded时间
async script:并行下载,谁先下载完谁先执行,不阻碍DOM渲染,不阻碍DOMContentLoaded时间
为什么我们要用async,在某项低版本iOS内核中,会等待页面DOMContentLoaded才开始渲染页面,如果用普通script或者defer模式,会延迟页面渲染时间。
03
降级策略
如果出现任何问题,为了保证用户可访问,我们会做出降级方案,也很简单:
抓取HTML本身的文本内容,同步到cdn,相当于把OPR模式退回到SPA了,这个策略在OPR方案中会有相应的检测方式和自动切换机制。
效果
OPR方案已经在我司多个业务接入,可以把原本首屏在1500ms-4000ms的SPA页面下降到300ms-800ms,可以说是效果显著了~
最后说明
OPR方案目前应用于我司各业务无需登录态的首页及重要列表渲染,下一阶段我们将解决对登录态有依赖的页面渲染,欢迎读者与我司交流探讨。
文末福利
转发本文并留下评论,我们将抽取第10名留言者(依据公众号后台顺序)送出转转纪念T恤一件:
扫描二维码
关注我们
一个有意思的前端团队